Skip to content

fix(disk-hygiene): stop guard hook fail-open on skill-hook data-root token - #1014

Merged
kyle-sexton merged 6 commits into
mainfrom
fix/983-skill-hook-data-root
Jul 22, 2026
Merged

fix(disk-hygiene): stop guard hook fail-open on skill-hook data-root token#1014
kyle-sexton merged 6 commits into
mainfrom
fix/983-skill-hook-data-root

Conversation

@kyle-sexton

@kyle-sexton kyle-sexton commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Problem

The clean skill's PreToolUse security guard silently never ran — a live fail-open. The
skill-frontmatter hook (skills/clean/SKILL.md) passed --authorized-data-root ${CLAUDE_PLUGIN_DATA}
in its args, but Claude Code refuses to launch a skill-scoped hook that references
${CLAUDE_PLUGIN_DATA}
(it is plugin-only; only ${CLAUDE_PLUGIN_ROOT} is available to skill
hooks) and treats the failed launch as a non-blocking error. So rm -rf, engine apply, and the
PowerShell deletion belt were all ungated.

This recurs the same fail-open shape prior fixes addressed, through a new vector (hook-launch
failure via an unsupported substitution token). The 0.4.4 premise — "inline placeholder substitution
resolves in exec-form hook args where environment injection does not" — does not hold for
${CLAUDE_PLUGIN_DATA} in a skill-scoped hook.

Fix

  • The hook now passes only --plugin-root ${CLAUDE_PLUGIN_ROOT} — the sole substitution a skill
    hook receives — so it always launches and the destructive-action guard runs again.
  • destructive_guard.py derives the authorized data root from the plugin root. The real install
    root is version-specific — <plugins>/cache/<marketplace>/<name>/<version> (a directly-linked
    local install omits the <version> leaf) — so the guard anchors on the <plugins>/cache marker
    (not a fixed depth): the two segments after cache are the marketplace and name, data is the
    sibling <plugins>/data/<id>, and <id> is the sanitized <name>@<marketplace>. Verified against
    the live install (cache/melodic-software/disk-hygiene/0.4.6data/disk-hygiene-melodic-software).
  • Every failure mode is fail-closed: an unrecognized layout yields no authority, so --data-root
    engine calls are denied while the destructive-action guard stays fully active. The direct
    --authorized-data-root flag and the CLAUDE_PLUGIN_DATA environment variable remain accepted as
    additional/fallback channels for hosts that can supply them.
  • Docs corrected (SKILL.md body, reference/safety-model.md) to describe the derivation instead
    of the now-false ${CLAUDE_PLUGIN_DATA}-hook-argument narrative — overlaps the doc-accuracy concern
    in disk-hygiene: doc corrections — Windows mislabeled 'full', false CLAUDE_PLUGIN_DATA premise repeated, PowerShell-lane gaps #386/D2.

Regression test for the launch path

Adds test_skill_hook_args_launch_with_only_skill_available_substitutions, which asserts every
${...} token in the declared hook command/args is within the skill-hook allowlist
(${CLAUDE_PLUGIN_ROOT}) — the two tests that previously asserted the buggy ${CLAUDE_PLUGIN_DATA}
/ ${user_config.*} tokens are gone. Also adds end-to-end coverage of the --plugin-root
data-root derivation through the guard, plus derivation unit tests. (Static assertion; that the hook
launches is only fully verifiable in a live Claude Code session.)

Known limitation (platform gap)

The disk_hygiene_enabled kill switch can no longer reach a skill-frontmatter guard. Its only
channels are the --disk-hygiene-enabled argv flag (which needs the ${user_config.*} substitution
skill hooks do not receive) and the CLAUDE_PLUGIN_OPTION_DISK_HYGIENE_ENABLED env var (which the
runtime does not inject into skill hooks). The guard therefore defaults to enabled and cannot honor a
configured false by denying outright; it still forces a human prompt before every mutation
(fail-safe), and the skill body's substituted value lets the model self-enforce audit-only. This
never functioned on 0.4.6 either (the hook did not launch at all), so it is a documented gap, not a
regression
. Fully honoring the kill switch in the guard needs a delivery channel skill hooks do not
yet have (a plugin-scoped hook/MCP server that can carry the value, or Claude Code adding
${user_config.*} substitution for skill hooks). Tracked as #1019.

Open question (does not block this fix)

Whether a skill-frontmatter hook process additionally receives CLAUDE_PLUGIN_DATA in its
environment is unconfirmed on current Claude Code (plugins-reference lists it as an available
path variable; #376 found it absent from a skill hook's env on 2.1.215). If present, the guard's
CLAUDE_PLUGIN_DATA env fallback carries the data root and the plugin-root derivation is a redundant
belt; if absent, the derivation is load-bearing. The derivation is written to be correct either way,
so this fact changes only which channel does the work, not correctness.

Verification

  • python -m unittest test_hygiene — 84 pass, 4 platform-skips.
  • ruff check clean; scripts/validate-plugins.sh, scripts/check-changelog-parity.sh --check-bump
    pass. Version bumped 0.4.7 → 0.4.8 with a matching CHANGELOG entry.

Sources

Fixes #983

Related

🤖 Generated with Claude Code

https://claude.ai/code/session_01SFq1q99cNeZjKhDzHv2BQw

…token

The clean skill's PreToolUse guard hook passed --authorized-data-root
${CLAUDE_PLUGIN_DATA} in its args, but Claude Code refuses to launch a
skill-frontmatter hook that references ${CLAUDE_PLUGIN_DATA} (plugin-only;
only ${CLAUDE_PLUGIN_ROOT} is available to skill hooks) and treats the
failed launch as a non-blocking error. The destructive-action guard
therefore never ran: rm -rf, engine apply, and the PowerShell deletion
belt were all ungated.

The hook now passes only --plugin-root ${CLAUDE_PLUGIN_ROOT} so it always
launches; destructive_guard.py derives the authorized data root from the
plugin root via Claude Code's documented persistent-data-directory layout
(<plugins>/data/<id>, id = sanitized <name>@<marketplace>). Every failure
mode is fail-closed: an unrecognized layout yields no authority, so
--data-root engine calls are denied while the guard stays fully active.
The direct --authorized-data-root and CLAUDE_PLUGIN_DATA env channels
remain accepted as fallbacks.

Known platform gap: the disk_hygiene_enabled kill switch can no longer
reach a skill-frontmatter guard (${user_config.*} is not substituted for
skill hooks and CLAUDE_PLUGIN_OPTION_* is not injected), so audit-only
mode degrades from deny-outright to human-prompt-gated; documented in the
safety model and CHANGELOG.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SFq1q99cNeZjKhDzHv2BQw
@claude

claude Bot commented Jul 22, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 4m 49s —— View job


Security Review — PR #1014

  • Gather context
  • Read changed files
  • Analyze destructive_guard.py for path traversal and injection issues
  • Analyze SKILL.md hook configuration for launch-path security
  • Analyze safety-model.md for doc accuracy vs implementation
  • Analyze test_hygiene.py for test-coverage gaps
  • Post security findings

Summary

The core fix is sound and closes a genuine fail-open: ${CLAUDE_PLUGIN_DATA} was silently rejected by the skill-hook launcher, leaving the destructive-action guard unexecuted. Replacing it with ${CLAUDE_PLUGIN_ROOT} is the correct response, and the fail-closed behaviour for unrecognised layout paths is implemented correctly. One IMPORTANT defence-in-depth gap was found in the new derivation function.


Findings

IMPORTANT — _plugin_data_root_from_root() accepts relative paths and would return a relative authority

Confidence: CONFIRMED (the missing check is definitively observable; exploitability requires a non-absolute ${CLAUDE_PLUGIN_ROOT})

destructive_guard.py line 136

root = Path(plugin_root)
parents = root.parents
if len(parents) < 3:
    return None
marketplace_dir, cache_dir, plugins_dir = parents[0], parents[1], parents[2]

A relative path such as plugins/cache/m/n passes every layout check:

  • parents[1].name = "cache"
  • parents[2].name = "plugins"

The function therefore returns "plugins/data/n-m" — a relative path. When _is_authorized_data_root() later calls _data_root_key()Path(value).expanduser().resolve(strict=False) on that relative authority, it resolves against the guard process's cwd rather than any intentional location. A model-provided --data-root pointing to the same cwd-relative location would pass the check, granting writes outside the plugin data directory.

In the current deployment this requires ${CLAUDE_PLUGIN_ROOT} to be non-absolute (Claude Code always provides absolute paths and the model cannot inject into hook args), so direct exploitability is low. However, _plugin_data_root_from_root() is intended to be a security boundary — the defence should be in code, not in a runtime guarantee external to the function. The test suite does not cover relative inputs.

Recommended fix: add an early return at the top of the function:

if not os.path.isabs(plugin_root):
    return None

And add a corresponding test case alongside the existing stray list:

self.assertIsNone(guard._plugin_data_root_from_root("plugins/cache/m/n"))

Fix this →


No other security issues were found. In particular:

  • The _SHELL_EXPANSION_OR_OPERATOR_CHARS rejection and the _literal_shell_words parser correctly block command injection in engine commands.
  • The _data_root_key()resolve(strict=False) normalization prevents ..-traversal in model-supplied --data-root values.
  • The _PLUGIN_ROOT_PLACEHOLDER check correctly treats an unsubstituted ${CLAUDE_PLUGIN_ROOT} literal as absent and fails closed.
  • The plugin_id sanitisation ([^A-Za-z0-9_-]-) prevents path traversal through marketplace or plugin-name components.
  • The documented kill-switch gap (cannot deny outright in skill-hook deployment) is correctly treated as fail-safe rather than fail-open: the guard still prompts before every mutation.

@claude

claude Bot commented Jul 22, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 3m 32s —— View job


PR #1014 Code Review

  • Gather context and understand the PR
  • Read changed files and analyze the fix
  • Review destructive_guard.py — derivation logic and fail-closed behavior
  • Review SKILL.md hook args change
  • Review test_hygiene.py — new test coverage
  • Review safety-model.md docs update
  • Review plugin.json version bump and CHANGELOG.md
  • Post final review

Summary

This PR correctly fixes a fail-open in the clean skill's PreToolUse guard: the hook previously declared ${CLAUDE_PLUGIN_DATA} in its args, which Claude Code refuses to substitute for skill hooks, causing the hook launch to fail silently (non-blocking error). The guard never ran. The fix replaces that token with ${CLAUDE_PLUGIN_ROOT} — the only substitution a skill hook receives — and derives the authorized data root from the plugin root via the documented <plugins>/cache/<marketplace>/<name><plugins>/data/<id> layout. Test coverage is thorough. Documentation is honest about the known kill-switch limitation.

Overall this is a well-structured security fix with correct fail-closed behavior throughout. One testability fragility and a couple of minor notes below.


Findings

Medium — Test isolation gap: test_guard_denies_data_root_without_hook_authority does not mock sys.argv

test_hygiene.py:1363

This test constructs its own mock context but omits mock.patch.object(guard.sys, "argv", ...). Before this PR, resolve_authorized_data_root() only read --authorized-data-root from argv — a flag no standard test runner would inject. Now it also reads --plugin-root, which it then runs through _plugin_data_root_from_root. If a test runner invocation happened to contain --plugin-root <path-matching-cache/mp/name> anywhere in its argv, the derivation would succeed and the test that expects a deny could instead get allow.

In practice this is low probability (the test expects a deny and the test environment controls argv), but it's a new fragility this PR introduces. All three run_guard_* helpers correctly patch sys.argv; this test should too:

with (
    mock.patch("sys.stdin", stdin),
    redirect_stdout(stdout),
    mock.patch.object(guard.sys, "argv", [str(SCRIPT_DIR / "destructive_guard.py")]),  # add this
    mock.patch.dict("os.environ", environment, clear=True),
):

Fix this →


Minor — _plugin_data_root_from_root docstring could acknowledge the sanitization collision risk

destructive_guard.py:123

The @- sanitization in:

plugin_id = _PLUGIN_ID_DISALLOWED.sub("-", f"{name}@{marketplace}")

can produce collisions — e.g. disk@hygiene-acme and disk-hygiene@acme both yield disk-hygiene-acme. The existing test at line 1643 documents this explicitly for . in names, which is good. The docstring says <id> is <name>@<marketplace> with disallowed characters replaced by -, which is accurate. No bug — this mirrors the documented Claude Code behavior. The test coverage is sufficient; just flagging it since the derivation now has security implications.


Minor — Coupling to undocumented plugins directory name

destructive_guard.py:143

if (
    cache_dir.name.casefold() != _PLUGIN_CACHE_DIRNAME
    or plugins_dir.name.casefold() != _PLUGINS_DIRNAME
):
    return None

The check for plugins as the parent directory name is the "one undocumented part" the safety-model.md correctly flags. The fail-closed design handles this cleanly — a future Claude Code layout change yields no authority rather than a wrong one. The docstring and safety model doc both acknowledge this, so no action needed beyond what's already documented.


What's correct and well done

Core fix: The hook args change from ${CLAUDE_PLUGIN_DATA} to --plugin-root ${CLAUDE_PLUGIN_ROOT} is exactly right. SKILL.md:13

Fail-closed derivation: _plugin_data_root_from_root returning None on any unrecognized layout, which flows through to _is_authorized_data_root(value, None) → False, denying --data-root engine calls while keeping the guard fully active. Correct throughout.

Precedence chain in resolve_authorized_data_root: --authorized-data-root--plugin-root derivation → CLAUDE_PLUGIN_DATA env. The exact-placeholder filter (!= _AUTHORIZED_DATA_ROOT_PLACEHOLDER) only rejects the literal unsubstituted token, not real paths containing ${ — the test at line 1587 explicitly covers this edge case. destructive_guard.py:196

Seam-lock tests: test_skill_hook_args_launch_with_only_skill_available_substitutions (line 1738) and test_skill_hook_passes_plugin_root_flag_matching_constant (line 1765) lock the config↔code contract so a future rename on either side doesn't silently reintroduce the fail-open. These are the right tests to have.

run_guard_plugin_root helper simulates the exact skill-hook deployment condition (no CLAUDE_PLUGIN_DATA in env, --plugin-root in argv), making the end-to-end test test_guard_derives_data_root_from_plugin_root_argv a faithful replica of the real runtime path.

Kill switch fail-safe: Defaulting to enabled = True when no channel delivers the value is the correct fail-safe behavior for the skill hook context (guard stays active and forces human prompt). The SKILL.md body correctly explains that the model — not the guard — enforces audit-only mode from its substituted disk_hygiene_enabled value. destructive_guard.py:208

Documentation accuracy: safety-model.md now honestly describes the derivation, the undocumented-layout coupling, and the kill-switch delivery gap. CHANGELOG and version bump are correct.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 834f98b73b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread plugins/disk-hygiene/skills/clean/scripts/destructive_guard.py Outdated
@kyle-sexton

Copy link
Copy Markdown
Contributor Author

🔒 merge-drive lane claiming this PR for on-branch fix work this cycle (amendment-round: 16, worker tier). Will fix clear branch-owned CI/bot findings, defer non-DEFER-FORBIDDEN items with reply+resolve, push, then re-check the merge gate.

kyle-sexton and others added 2 commits July 22, 2026 12:19
The install root is the version leaf (<plugins>/cache/<marketplace>/<name>/
<version>), so the guard's derivation must not assume CLAUDE_PLUGIN_ROOT is
the <name> directory. Anchor on the <plugins>/cache marker instead of a
fixed parent depth: the two segments after cache are the marketplace and
name, data is cache's sibling, and any version leaf is ignored. Handles the
directly-linked local install (no version leaf) too. The prior fixed-depth
check returned None on every real install (fail-closed, but the mechanism
was dead), and its tests used a fabricated version-less layout that hid it;
tests now use the real versioned shape and the dev shape.

Docs corrected to the real cache/<marketplace>/<name>/<version> shape and to
stop claiming state "provably" lands via derivation; note that whether a
skill hook also receives CLAUDE_PLUGIN_DATA in env is unconfirmed, so the
derivation is written to stand on its own either way.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SFq1q99cNeZjKhDzHv2BQw
The machine-specific-paths CI lane flags a fabricated /home/<user> path.
Switch the derivation fixtures to the neutral /x/plugins/... form the
sibling test already uses; the derivation only needs the plugins/cache
structure, not a home-rooted path.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SFq1q99cNeZjKhDzHv2BQw
@kyle-sexton kyle-sexton reopened this Jul 22, 2026
Reconcile disk-hygiene conflicts from concurrent main advances (#1010, #1012,
#1028, #1031) that landed while this branch was open.

- plugin.json / CHANGELOG: restack this branch's fix onto main's version line,
  0.4.8 -> 0.6.3 (next patch above main's landed 0.6.2); preserve every landed
  CHANGELOG entry (0.6.2 / 0.6.1 / 0.6.0 / 0.5.0) with this branch's entry on top.
- clean/SKILL.md: compose #983's corrected kill-switch framing (a skill-scoped
  hook receives neither ${user_config.*} nor CLAUDE_PLUGIN_OPTION_*, so the guard
  cannot enforce audit-only) with #1012's deterministic kill_switch_probe read,
  dropping main's stale "guard enforces via --disk-hygiene-enabled" claim that
  #983's hook-arg change (only --plugin-root) invalidated.
- clean/reference/safety-model.md: keep #983's --data-root derivation-from-
  plugin-root rewrite and append #1010's --confirmed-large-scan grammar note.

destructive_guard.py and test_hygiene.py auto-merged (data-root derivation +
--confirmed-large-scan + kill_switch_probe allowlist + MIN_PYTHON floor); full
suite 98 passed, 4 platform-skipped.
@claude

claude Bot commented Jul 22, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 4m 15s —— View job


Security Review — PR #1014

  • Gather context and understand the PR
  • Read changed files via gh pr diff
  • Analyze destructive_guard.py — path derivation, injection surfaces, authorization logic
  • Verify whether prior findings (relative-path guard, test isolation gap, versioned-layout depth) were addressed
  • Check SKILL.md hook configuration for new attack surface
  • Analyze test_hygiene.py for seam-lock coverage gaps
  • Post security findings

Summary

The core fix is correct and addresses a real fail-open: replacing ${CLAUDE_PLUGIN_DATA} with ${CLAUDE_PLUGIN_ROOT} in the skill hook args ensures the guard process launches. The new _plugin_data_root_from_root derivation uses a marker-scan approach (parts[i]=="cache" and parts[i-1]=="plugins") rather than fixed-depth parents[] indexing — this directly resolves the Codex P1 finding (versioned <name>/<version> layout) that was filed against commit 834f98b. That finding is resolved by commit fbf3ba5. Two prior findings from the initial security/code reviews remain open.


Findings

IMPORTANT — _plugin_data_root_from_root still accepts relative paths and returns a relative authority

Confidence: CONFIRMED — this exact finding was raised by the prior security review (comment 2026-07-22T16:09:29Z) and is NOT addressed in any commit on this branch.

destructive_guard.py line 143–157

parts = Path(plugin_root).parts
for index in range(1, len(parts)):
    if not (
        parts[index].casefold() == _PLUGIN_CACHE_DIRNAME
        and parts[index - 1].casefold() == _PLUGINS_DIRNAME
    ):
        continue
    ...
    plugins_dir = Path(*parts[:index])
    return os.fspath(plugins_dir / _PLUGIN_DATA_DIRNAME / plugin_id)

For a relative input such as plugins/cache/m/n, parts[0]="plugins" and parts[1]="cache" match at index=1. Path(*parts[:1]) = Path("plugins") — relative. The returned authority is "plugins/data/n-m" — also relative. When _is_authorized_data_root() later calls _data_root_key()Path(value).expanduser().resolve(strict=False), both sides resolve against the guard process's cwd, meaning a crafted --data-root pointing to the same cwd-relative location would pass the check.

In the skill-hook deployment this requires ${CLAUDE_PLUGIN_ROOT} to be non-absolute (Claude Code always supplies absolute paths and the model cannot inject into hook args), so direct exploitability is low. But _plugin_data_root_from_root is a security boundary: the defence should be in code, not reliant on an external guarantee. The test suite does not cover relative inputs.

Recommended fix (same as prior review's recommendation):

def _plugin_data_root_from_root(plugin_root: str) -> str | None:
    if not os.path.isabs(plugin_root):
        return None
    ...

Add a corresponding test assertion alongside the existing stray list in test_plugin_data_root_from_root_follows_documented_layout:

self.assertIsNone(guard._plugin_data_root_from_root("plugins/cache/m/n"))

Fix this →


SUGGESTION — test_guard_denies_data_root_without_hook_authority does not mock sys.argv

Confidence: CONFIRMED — this exact finding was raised by the prior code review (comment 2026-07-22T16:09:32Z) and is NOT addressed in any commit on this branch.

test_hygiene.py line 1583–1588

with (
    mock.patch("sys.stdin", stdin),
    redirect_stdout(stdout),
    mock.patch.dict("os.environ", environment, clear=True),  # no sys.argv patch
):
    self.assertEqual(0, guard.main())

Since resolve_authorized_data_root() now also reads --plugin-root from sys.argv, a test runner invocation containing --plugin-root <path-matching-cache/mp/name> in its real argv could cause the derivation to succeed, producing an allow where the test expects deny. All other run_guard_* helpers correctly patch sys.argv; this one does not. Low probability in practice, but it is a new fragility this PR introduces relative to the old code.

Recommended fix:

with (
    mock.patch("sys.stdin", stdin),
    redirect_stdout(stdout),
    mock.patch.object(guard.sys, "argv", [str(SCRIPT_DIR / "destructive_guard.py")]),
    mock.patch.dict("os.environ", environment, clear=True),
):

Fix this →


What is verified correct

  • Codex P1 resolved: The versioned-layout depth issue (parents[1] off by one for installed <name>/<version> roots) was the old 834f98b approach. Commit fbf3ba5 ("fix(disk-hygiene): derive data root from real versioned install layout") replaced it with the parts marker-scan that correctly handles both <name>/<version> and direct-link <name> roots. The Codex finding does not apply to the current code.
  • Hook args are not model-injectable: The new hook passes only ["…/destructive_guard.py", "--plugin-root", "${CLAUDE_PLUGIN_ROOT}"]. Both are harness-substituted; the model has no injection surface.
  • Fail-closed on unrecognized layout: _plugin_data_root_from_root returning None_is_authorized_data_root(value, None)False. Engine calls with --data-root are denied while the guard stays fully active.
  • No new injection surfaces in _argv_flag_value: reads only from sys.argv, which in skill-hook deployment is harness-controlled.
  • Seam-lock tests are correct: test_skill_hook_args_launch_with_only_skill_available_substitutions and test_skill_hook_passes_plugin_root_flag_matching_constant lock the config↔code contract; a rename on either side will fail the test.
  • run_guard_plugin_root helper faithfully replicates skill-hook conditions (no CLAUDE_PLUGIN_DATA in env, --plugin-root in argv only).

@claude

claude Bot commented Jul 22, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 3m 31s —— View job


PR #1014 Review

  • Gather context
  • Read changed files
  • Analyze Codex P1 finding: versioned cache path depth
  • Verify prior security findings (relative path guard, sys.argv mock)
  • Post final review

Codex P1: Versioned cache path — Resolved

The Codex review was based on commit 834f98b, which used fixed-depth parents[0/1/2] indexing that would fail for versioned installs (cache/<mkt>/<name>/<version>). Commit fbf3ba5 already fixes this by replacing fixed depth with an index-scanning approach that iterates Path.parts looking for the plugins/cache marker:

parts = Path(plugin_root).parts
for index in range(1, len(parts)):
    if not (
        parts[index].casefold() == _PLUGIN_CACHE_DIRNAME
        and parts[index - 1].casefold() == _PLUGINS_DIRNAME
    ):
        continue
    marketplace, name = parts[index + 1], parts[index + 2]

For a versioned install at …/plugins/cache/melodic-software/disk-hygiene/0.4.6, the loop finds cache at its correct position, takes parts[index+1] (marketplace) and parts[index+2] (name), and the version leaf is silently ignored — exactly the documented behaviour. test_plugin_data_root_from_root_follows_documented_layout now covers both the versioned and unversioned layouts explicitly (lines 1841–1856). The Codex P1 does not apply to the current branch tip.


Still-open: Relative path not rejected in _plugin_data_root_from_root

The previous security review's IMPORTANT finding remains unresolved. _plugin_data_root_from_root has no early return for non-absolute inputs:

destructive_guard.py:143

A relative input like plugins/cache/m/n satisfies all layout checks (parts[1]="cache" under parts[0]="plugins") and returns plugins/data/n-m — a relative path. When _is_authorized_data_root later calls _data_root_key()resolve(strict=False) on that relative authority, it anchors to the guard process's cwd rather than any intentional location. A model-supplied --data-root plugins/data/n-m would then match and be authorized.

Exploitability in this deployment is very low — Claude Code always provides an absolute ${CLAUDE_PLUGIN_ROOT}, and the model cannot inject into hook args — but _plugin_data_root_from_root is a security boundary and should not depend on an external runtime guarantee. The test suite at line 1867 covers stray absolute paths but has no relative-path case.

Recommended fix (two lines):

def _plugin_data_root_from_root(plugin_root: str) -> str | None:
    if not os.path.isabs(plugin_root):   # add this
        return None                       # add this
    parts = Path(plugin_root).parts
    ...

And add one assertion to the stray list in test_plugin_data_root_from_root_follows_documented_layout:

"plugins/cache/m/n",   # relative path must fail closed

Fix this →


Still-open: test_guard_denies_data_root_without_hook_authority does not patch sys.argv

test_hygiene.py:1583

The test constructs its own mock context but omits mock.patch.object(guard.sys, "argv", ...). The guard now reads --plugin-root from sys.argv (in addition to --authorized-data-root). If a test runner happened to pass --plugin-root <path-matching-cache/mkt/name>, the derivation would succeed and the test that expects deny would get allow instead. Low probability in practice, but it's a fragility this PR introduced. All three run_guard_* helpers correctly patch sys.argv; this test should too:

with (
    mock.patch("sys.stdin", stdin),
    redirect_stdout(stdout),
    mock.patch.object(guard.sys, "argv", [str(SCRIPT_DIR / "destructive_guard.py")]),  # add
    mock.patch.dict("os.environ", environment, clear=True),
):

Fix this →


What's correct and well done

  • Core fix is sound. The hook args change from ${CLAUDE_PLUGIN_DATA}--plugin-root ${CLAUDE_PLUGIN_ROOT} is the right fix and the seam-lock tests (test_skill_hook_args_launch_with_only_skill_available_substitutions, test_skill_hook_passes_plugin_root_flag_matching_constant) confirm the config↔code contract statically.
  • Versioned layout derivation (added in fbf3ba5) correctly handles the <version> leaf and the no-version local-install case with a single index-scan loop.
  • Fail-closed derivation_plugin_data_root_from_root returning None on unrecognized layout flows correctly through to deny on --data-root calls while keeping the guard active.
  • run_guard_plugin_root helper simulates the exact skill-hook condition (no CLAUDE_PLUGIN_DATA in env, --plugin-root in argv) making test_guard_derives_data_root_from_plugin_root_argv a faithful integration replica of the real runtime path.
  • Kill switch fail-safe — defaulting enabled = True when no channel delivers the value is the correct behaviour for a skill-hook deployment; the model still enforces audit-only mode from its substituted disk_hygiene_enabled value.
  • Documentation — SKILL.md and safety-model.md are accurate about the derivation, the undocumented-layout coupling, and the kill-switch delivery gap.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f832eee3c8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread plugins/disk-hygiene/skills/clean/scripts/destructive_guard.py
…itation

A `claude --plugin-dir <checkout>` session has no <plugins>/cache/<marketplace>
structure and no marketplace-keyed data id, so the guard cannot derive an
authorized data root and relies solely on the CLAUDE_PLUGIN_DATA environment
variable (which the plugins reference documents as exported to hook processes).
Where a build does not export it to a skill hook, the engine lane is fail-closed
in that dev-only mode while the destructive-action guard stays active — a
deliberate safe-over-convenient tradeoff, not a security gap. Records this in the
safety model in response to review feedback.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SFq1q99cNeZjKhDzHv2BQw
@claude

claude Bot commented Jul 22, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 2m 18s —— View job


PR #1014 Review

  • Gather context
  • Read destructive_guard.py — check if relative-path guard was added
  • Read test_hygiene.py — check if sys.argv mock was added
  • Review safety-model.md — assess new docs commit vs Codex P2
  • Post final review

Status after latest commit (d8b32b8)

The latest commit is docs-only (safety-model.md). The two findings from prior security and code reviews remain unaddressed in code.


Still open: _plugin_data_root_from_root accepts relative paths

IMPORTANT — Confidence: CONFIRMED — raised in all three prior reviews; no commit has addressed it.

destructive_guard.py:143

parts = Path(plugin_root).parts
for index in range(1, len(parts)):
    if not (
        parts[index].casefold() == _PLUGIN_CACHE_DIRNAME
        and parts[index - 1].casefold() == _PLUGINS_DIRNAME
    ):
        continue
    ...
    plugins_dir = Path(*parts[:index])
    return os.fspath(plugins_dir / _PLUGIN_DATA_DIRNAME / plugin_id)

For a relative input plugins/cache/m/n, parts[0]="plugins" and parts[1]="cache" match at index=1. Path(*parts[:1]) = Path("plugins") — relative. The returned authority is "plugins/data/n-m" — also relative. When _is_authorized_data_root() later calls _data_root_key()resolve(strict=False), both sides anchor to the guard process's cwd, so a crafted --data-root plugins/data/n-m would match and be authorized.

Exploitability is low given Claude Code always provides an absolute ${CLAUDE_PLUGIN_ROOT} and the model cannot inject into hook args. But _plugin_data_root_from_root is a security boundary and must not rely on an external runtime guarantee. The stray test list at line 1867 covers only absolute mismatches — no relative-path case is present.

Fix (two lines of code + one test assertion):

def _plugin_data_root_from_root(plugin_root: str) -> str | None:
    if not os.path.isabs(plugin_root):
        return None
    parts = Path(plugin_root).parts
    ...

And add "plugins/cache/m/n" to the stray list in test_plugin_data_root_from_root_follows_documented_layout.

Fix this →


Still open: test_guard_denies_data_root_without_hook_authority does not patch sys.argv

SUGGESTION — Confidence: CONFIRMED — raised in all three prior reviews; no commit has addressed it.

test_hygiene.py:1583

with (
    mock.patch("sys.stdin", stdin),
    redirect_stdout(stdout),
    mock.patch.dict("os.environ", environment, clear=True),  # no sys.argv patch
):
    self.assertEqual(0, guard.main())
result = json.loads(stdout.getvalue())
self.assertEqual("deny", result["hookSpecificOutput"]["permissionDecision"])

resolve_authorized_data_root() now reads --plugin-root from sys.argv in addition to --authorized-data-root. The test correctly excludes CLAUDE_PLUGIN_DATA from the environment, but if the test runner's real sys.argv happened to contain --plugin-root <absolute-path-with-cache/mp/name>, the derivation would succeed and the test that expects "deny" would get "allow" instead — or vice-versa if the derived path happened to match the temp directory. All three run_guard_* helpers patch sys.argv; this test should too:

with (
    mock.patch("sys.stdin", stdin),
    redirect_stdout(stdout),
    mock.patch.object(guard.sys, "argv", [str(SCRIPT_DIR / "destructive_guard.py")]),
    mock.patch.dict("os.environ", environment, clear=True),
):

Fix this →


Codex P2 (--plugin-dir local dev) — Addressed by docs

d8b32b8 documents in safety-model.md that a claude --plugin-dir <checkout> session has no <plugins>/cache/<marketplace> structure and therefore no derivable authority. The guard's fail-closed behaviour in that mode is correctly described as a deliberate safe-over-convenient tradeoff for dev-only use, not a security gap. The Codex P2 finding is resolved by documentation; no code change is needed for that scenario.


What remains correct

  • Core fix (hook args): ${CLAUDE_PLUGIN_DATA}--plugin-root ${CLAUDE_PLUGIN_ROOT} is sound. Seam-lock tests confirm the config↔code contract.
  • Fail-closed derivation: _plugin_data_root_from_root returning None on unrecognized layout flows correctly to deny while keeping the guard active.
  • Index-scan for versioned layout: The marker-scan in fbf3ba5 correctly handles both <name>/<version> and no-version roots; the Codex P1 finding does not apply to the current code.
  • Kill switch fail-safe: Defaulting enabled = True when no channel delivers the value is the correct behavior in skill-hook context.
  • Documentation: safety-model.md now accurately describes derivation, undocumented-layout coupling, kill-switch delivery gap, and the --plugin-dir dev mode limitation.

The two open findings above are the only unresolved items from the full review cycle.

@claude

claude Bot commented Jul 22, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 5m 2s —— View job


Security Review — PR #1014

  • Gather PR diff and read changed files
  • Check whether prior unresolved findings were addressed in latest commit (d8b32b8 — docs-only)
  • Analyze destructive_guard.py for new or remaining security issues
  • Post findings

Summary

The latest commit (d8b32b8) is documentation-only (safety-model.md). The two security findings raised by every prior review round remain unaddressed in the current branch tip. One new defence-in-depth gap is noted in the is_exact_kill_switch_probe function that arrived via the merge from origin/main.


IMPORTANT — _plugin_data_root_from_root still accepts relative paths

Severity: IMPORTANT
Confidence: CONFIRMED — the missing check is directly observable in the code; exploitability requires a non-absolute ${CLAUDE_PLUGIN_ROOT}

destructive_guard.py:143

The function has no absolute-path guard. For a relative input such as plugins/cache/m/n:

  • parts = ("plugins", "cache", "m", "n") — the marker scan matches at index=1
  • Path(*parts[:1])Path("plugins") — relative
  • Returns "plugins/data/n-m" — also relative

When _is_authorized_data_root() later canonicalises this via _data_root_key()resolve(strict=False), both the derived authority and a crafted --data-root plugins/data/n-m resolve against the guard process's cwd. A model-supplied --data-root pointing to the same cwd-relative location would then match and be authorized.

In the current skill-hook deployment ${CLAUDE_PLUGIN_ROOT} is always absolute (Claude Code guarantees it and the model cannot inject into hook args), so direct exploitability is low — but _plugin_data_root_from_root is a security boundary that should not rely on an external runtime guarantee.

This finding was raised in the first security review (2026-07-22T16:09:29Z), repeated in the second (2026-07-22T17:07:07Z), and again in the third. It remains unaddressed in all five commits pushed since then.

Recommended fix — two lines at the top of _plugin_data_root_from_root:

if not os.path.isabs(plugin_root):
    return None

And add one entry to the stray list in test_plugin_data_root_from_root_follows_documented_layout:

"plugins/cache/m/n",   # relative path must fail closed

Fix this →


SUGGESTION — test_guard_denies_data_root_without_hook_authority does not mock sys.argv

Severity: SUGGESTION
Confidence: CONFIRMED — the missing patch is directly observable

test_hygiene.py:1583

with (
    mock.patch("sys.stdin", stdin),
    redirect_stdout(stdout),
    mock.patch.dict("os.environ", environment, clear=True),  # no sys.argv patch
):

resolve_authorized_data_root() now reads --plugin-root from sys.argv in addition to --authorized-data-root. If a test runner passed --plugin-root /x/plugins/cache/m/n in its real argv, the derivation would succeed and the test that expects deny would receive allow. All three run_guard_* helpers correctly patch sys.argv; this test should too. Raised in the first code review (2026-07-22T16:09:32Z) and again in the second. Remains unaddressed.

Recommended fix:

with (
    mock.patch("sys.stdin", stdin),
    redirect_stdout(stdout),
    mock.patch.object(guard.sys, "argv", [str(SCRIPT_DIR / "destructive_guard.py")]),
    mock.patch.dict("os.environ", environment, clear=True),
):

Fix this →


SUGGESTION — is_exact_kill_switch_probe fails open when kill_switch_probe.py is absent from disk

Severity: SUGGESTION
Confidence: CONFIRMED — behaviour is directly observable from _script_path_key's contract; exploitability requires the script file to be missing

destructive_guard.py:361

def is_exact_kill_switch_probe(command: str) -> bool:
    ...
    expected_script = str(
        Path(__file__).resolve().parents[2] / "setup" / "scripts" / "kill_switch_probe.py"
    )
    return _script_path_key(tokens[1]) == _script_path_key(expected_script)

_script_path_key calls Path(value).resolve(strict=True) and returns None on OSError. When kill_switch_probe.py doesn't exist on disk, _script_path_key(expected_script) returns None. Any model-supplied tokens[1] that also doesn't exist returns None, and None == None is True — the function grants "allow" for the command.

Constraints limit harm: the command must be exactly 2 tokens with no shell expansion (_literal_shell_words rejects all operator/expansion characters); tokens[0] must be the real Python executable validated by os.path.samefile; and the allowed command (python <nonexistent>) fails at execution anyway. Exploiting this to bypass a deny verdict requires the model to know the exact Python executable path and kill_switch_probe.py to be absent — unusual for a working install. The pre-existing classify_exact_engine_command carries the same None != None pattern for hygiene.py (prior reviews accepted this implicitly).

Recommended fix: add an explicit existence check before the comparison:

expected_key = _script_path_key(expected_script)
if expected_key is None:
    return False  # script absent — fail closed rather than None == None
return _script_path_key(tokens[1]) == expected_key

What is verified correct at HEAD

  • Core fix is sound: replacing ${CLAUDE_PLUGIN_DATA} with --plugin-root ${CLAUDE_PLUGIN_ROOT} in SKILL.md ensures the guard process launches. The seam-lock tests lock the config↔code contract.
  • Versioned-layout derivation (commit fbf3ba5): the index-scan over Path.parts correctly handles both <name>/<version> and bare <name> install roots; the Codex P1 finding from 834f98b does not apply to the current code.
  • Fail-closed on unrecognised layout: _plugin_data_root_from_root returning None_is_authorized_data_root(value, None)False. Engine calls with --data-root are denied while the destructive-action guard stays active.
  • No new injection surfaces: _literal_shell_words rejects all shell expansion and operator characters; _data_root_key()resolve(strict=False) prevents ..-traversal in model-supplied paths; _PLUGIN_ID_DISALLOWED sanitisation prevents path traversal through marketplace/name components.
  • --plugin-dir local-dev gap documented (d8b32b8): correctly characterised in safety-model.md as a deliberate safe-over-convenient tradeoff, not a security gap. The Codex P2 finding is addressed by documentation.
  • Kill-switch fail-safe: defaulting enabled = True when no channel delivers the value is the correct fail-safe behaviour for a skill-hook deployment.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d8b32b8c8c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread plugins/disk-hygiene/skills/clean/SKILL.md
@kyle-sexton
kyle-sexton enabled auto-merge (squash) July 22, 2026 18:27
Two live docs still said the skill-scoped guard enforces the disk_hygiene_enabled
kill switch, which stopped holding once the guard's hook stopped receiving the
toggle (a skill-frontmatter hook gets neither the ${user_config.*} substitution
nor CLAUDE_PLUGIN_OPTION_*). The README security-review bullet and the
kill_switch_probe.py docstring now state the degraded-mode reality documented in
the safety model: audit-only is model-enforced (the clean skill self-enforces via
the probe), the guard cannot deny on the toggle but still gates every mutation
behind a human prompt, and a direct hygiene.py call answers only to the engine's
own preview/approval gate. README points at the safety model for the detail.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SFq1q99cNeZjKhDzHv2BQw
@claude

claude Bot commented Jul 22, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 3m 12s —— View job


PR #1014 Review

  • Gather context and understand the PR
  • Read latest changed files (HEAD: 6f05652)
  • Check whether persistent open findings were addressed
  • Check Codex P2 finding (docs accuracy for kill switch)
  • Post final review

Status after HEAD (6f05652 — docs-only commit)

The latest commit correctly updates README.md and kill_switch_probe.py to reflect that audit-only mode is model-enforced in the skill-hook deployment, not guard-enforced. That resolves the Codex P2 finding. The two code-level findings raised in every prior review round remain unaddressed.


IMPORTANT — _plugin_data_root_from_root still accepts relative paths (round 4)

Confidence: CONFIRMED — the missing check is directly observable; raised in all four prior reviews, unaddressed across all six commits on this branch.

destructive_guard.py:143

parts = Path(plugin_root).parts       # no absoluteness guard here
for index in range(1, len(parts)):
    if not (
        parts[index].casefold() == _PLUGIN_CACHE_DIRNAME
        and parts[index - 1].casefold() == _PLUGINS_DIRNAME
    ):
        continue
    ...
    plugins_dir = Path(*parts[:index])  # relative if plugin_root was relative
    return os.fspath(plugins_dir / _PLUGIN_DATA_DIRNAME / plugin_id)  # relative authority

For a relative input plugins/cache/m/n, parts[0]="plugins" and parts[1]="cache" match at index=1. Path(*parts[:1]) = Path("plugins") — relative. The returned authority is "plugins/data/n-m" — also relative. When _is_authorized_data_root() later calls _data_root_key()resolve(strict=False), both sides anchor to the guard process's cwd; a crafted --data-root plugins/data/n-m would match and be authorized.

Exploitability in deployment is low — Claude Code guarantees an absolute ${CLAUDE_PLUGIN_ROOT} and the model cannot inject into hook args — but _plugin_data_root_from_root is a security boundary. The stray-path test list at line 1867 covers only absolute mismatches; no relative-path case is present.

Fix — two lines of code:

def _plugin_data_root_from_root(plugin_root: str) -> str | None:
    if not os.path.isabs(plugin_root):  # add
        return None                      # add
    parts = Path(plugin_root).parts
    ...

And add one entry to the stray tuple in test_plugin_data_root_from_root_follows_documented_layout at line 1867:

"plugins/cache/m/n",   # relative path must fail closed

Fix this →


SUGGESTION — test_guard_denies_data_root_without_hook_authority does not patch sys.argv (round 3)

Confidence: CONFIRMED — raised in all three prior code-review rounds, unaddressed.

test_hygiene.py:1583

with (
    mock.patch("sys.stdin", stdin),
    redirect_stdout(stdout),
    mock.patch.dict("os.environ", environment, clear=True),  # no sys.argv patch
):
    self.assertEqual(0, guard.main())

resolve_authorized_data_root() now reads --plugin-root from sys.argv in addition to --authorized-data-root. If a test runner passed --plugin-root /x/plugins/cache/m/n in real argv, the derivation would succeed and the expected "deny" would become "allow". All three run_guard_* helpers correctly patch sys.argv; this test should too:

with (
    mock.patch("sys.stdin", stdin),
    redirect_stdout(stdout),
    mock.patch.object(guard.sys, "argv", [str(SCRIPT_DIR / "destructive_guard.py")]),
    mock.patch.dict("os.environ", environment, clear=True),
):

Fix this →


SUGGESTION — is_exact_kill_switch_probe fails open when kill_switch_probe.py is absent from disk (carried from prior review)

Confidence: CONFIRMED — raised in the 2026-07-22T18:13:30Z security review; unaddressed.

destructive_guard.py:361

expected_script = str(
    Path(__file__).resolve().parents[2] / "setup" / "scripts" / "kill_switch_probe.py"
)
return _script_path_key(tokens[1]) == _script_path_key(expected_script)

_script_path_key calls resolve(strict=True) and returns None on OSError. When kill_switch_probe.py is absent from disk, _script_path_key(expected_script) = None; any model-supplied tokens[1] that also fails resolve returns None, and None == None is True — granting an allow incorrectly. Harm is constrained: the command must be exactly 2 tokens with no shell expansion, and tokens[0] must be the real Python executable; but the boundary should be explicit. Recommended fix:

expected_key = _script_path_key(expected_script)
if expected_key is None:
    return False   # script absent — fail closed rather than None == None
return _script_path_key(tokens[1]) == expected_key

Codex P2 — Resolved ✓

Commit 6f05652 correctly updates both README.md (security-review bullet at lines 139–147) and kill_switch_probe.py (module docstring at lines 12–19) to reflect that audit-only mode is model-enforced (via the probe) rather than guard-enforced. The guard's actual guarantee — forcing a human prompt before every mutation — is preserved and accurately stated in both places. No further docs fix is needed for this finding.


What remains correct and well done

  • Core fix is sound. Hook args ${CLAUDE_PLUGIN_DATA}--plugin-root ${CLAUDE_PLUGIN_ROOT} ensures the guard process launches. Seam-lock tests (test_skill_hook_args_launch_with_only_skill_available_substitutions, test_skill_hook_passes_plugin_root_flag_matching_constant) lock the config↔code contract.
  • Versioned-layout derivation (fbf3ba5) correctly handles both <name>/<version> and bare <name> roots via index-scan; Codex P1 does not apply to current code.
  • Fail-closed on unrecognized layout: _plugin_data_root_from_root returning None_is_authorized_data_root(value, None)False. Engine --data-root calls are denied while the guard stays active.
  • run_guard_plugin_root helper faithfully replicates skill-hook conditions (no CLAUDE_PLUGIN_DATA in env, --plugin-root in argv only), making test_guard_derives_data_root_from_plugin_root_argv a faithful end-to-end replica of the real runtime path.
  • Kill switch fail-safe: defaulting enabled = True when no channel delivers the value is the correct behaviour; the model still enforces audit-only mode from the probe result.
  • Documentation is now accurate end-to-end: safety-model.md, SKILL.md, README.md, and kill_switch_probe.py all correctly describe the derivation, undocumented-layout coupling, and kill-switch delivery gap.

The one IMPORTANT (relative-path absoluteness guard) and two SUGGESTION findings above are the only unresolved items across the full review cycle. The IMPORTANT finding is a two-line code change that closes a defence-in-depth gap in a security boundary; it is the only item that should block merge.

@claude

claude Bot commented Jul 22, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 3m 32s —— View job


Security Review — PR #1014

  • Gather PR diff and identify changed files at HEAD (6f05652)
  • Read destructive_guard.py — check if relative-path guard was added
  • Read test_hygiene.py — check if sys.argv mock was added
  • Check kill_switch_probe.py and is_exact_kill_switch_probe — assess None == None finding
  • Review SKILL.md, README.md, docs for Codex P2 kill-switch doc accuracy
  • Post security findings

Summary

HEAD is 6f05652 (docs-only: corrects stale audit-only enforcement claims in README and kill_switch_probe.py). Two prior findings remain unresolved in code. One pre-existing None == None defect in is_exact_kill_switch_probe carries only theoretical risk but is a correctness issue in a security boundary.


IMPORTANT — _plugin_data_root_from_root still accepts relative paths

Confidence: CONFIRMED — the missing check is directly observable at HEAD; no commit in this branch has addressed it despite being raised in every prior review round.

destructive_guard.py:143

parts = Path(plugin_root).parts
for index in range(1, len(parts)):
    if not (
        parts[index].casefold() == _PLUGIN_CACHE_DIRNAME
        and parts[index - 1].casefold() == _PLUGINS_DIRNAME
    ):
        continue
    ...
    plugins_dir = Path(*parts[:index])
    return os.fspath(plugins_dir / _PLUGIN_DATA_DIRNAME / plugin_id)

For a relative input such as plugins/cache/m/n, parts[0]="plugins" and parts[1]="cache" match at index=1. Path(*parts[:1]) = Path("plugins") — relative. The returned authority is "plugins/data/n-m" — also relative. When _is_authorized_data_root() later calls _data_root_key()resolve(strict=False), both the derived authority and a model-supplied --data-root plugins/data/n-m resolve against the guard process's cwd, so the check would match and the call would be authorized.

Exploitability requires ${CLAUDE_PLUGIN_ROOT} to be non-absolute; Claude Code always provides absolute paths and the model cannot inject into hook args. But _plugin_data_root_from_root is a security boundary whose correctness must not rely on that external runtime guarantee. The stray test list at line 1867 covers absolute mismatches only — no relative-path case is present.

Fix (two lines + one test assertion):

def _plugin_data_root_from_root(plugin_root: str) -> str | None:
    if not os.path.isabs(plugin_root):
        return None
    parts = Path(plugin_root).parts
    ...

Add "plugins/cache/m/n" to the stray tuple in test_plugin_data_root_from_root_follows_documented_layout.

Fix this →


SUGGESTION — test_guard_denies_data_root_without_hook_authority still does not mock sys.argv

Confidence: CONFIRMED — the missing patch is directly observable at HEAD; raised in every prior review round.

test_hygiene.py:1583

with (
    mock.patch("sys.stdin", stdin),
    redirect_stdout(stdout),
    mock.patch.dict("os.environ", environment, clear=True),  # no sys.argv patch
):
    self.assertEqual(0, guard.main())

resolve_authorized_data_root() now reads --plugin-root from sys.argv. If a test runner passed --plugin-root /x/plugins/cache/m/n in its real argv, the derivation would succeed and the test that expects deny would receive allow. All three run_guard_* helpers correctly patch sys.argv; this test should too:

with (
    mock.patch("sys.stdin", stdin),
    redirect_stdout(stdout),
    mock.patch.object(guard.sys, "argv", [str(SCRIPT_DIR / "destructive_guard.py")]),
    mock.patch.dict("os.environ", environment, clear=True),
):

Fix this →


SUGGESTION — is_exact_kill_switch_probe fails open when kill_switch_probe.py is absent and a nonexistent path is supplied

Confidence: CONFIRMED — the None == None case is directly observable; practical impact is bounded because the allowed command fails at execution.

destructive_guard.py:375

return _script_path_key(tokens[1]) == _script_path_key(expected_script)

_script_path_key returns None on OSError (file absent). When kill_switch_probe.py does not exist on disk, _script_path_key(expected_script) returns None. A model-supplied tokens[1] pointing to any nonexistent path also returns None, so None == NoneTrue — the function returns True and the guard emits "allow" for a "kill-switch probe" that is in fact python <nonexistent-path>.

Practical impact is limited: the attack requires kill_switch_probe.py to be absent (degraded install) AND the model to supply a nonexistent script path (a model-supplied path that exists would return a real key, not None, making the comparison fail). The "allowed" command fails at execution regardless. However, this is a correctness error in a security boundary that mirrors the same pattern in classify_exact_engine_command for hygiene.py.

Recommended fix:

def is_exact_kill_switch_probe(command: str) -> bool:
    ...
    expected_key = _script_path_key(expected_script)
    if expected_key is None:
        return False  # script absent — fail closed, not None == None
    return _script_path_key(tokens[1]) == expected_key

Fix this →


What is resolved at HEAD

  • Codex P1 (versioned layout depth): fbf3ba5 replaced fixed-depth parents[] with an index-scan over Path.parts that correctly handles <name>/<version> and bare <name> roots. Confirmed resolved.
  • Codex P2 (--plugin-dir local dev): d8b32b8 documents in safety-model.md that a claude --plugin-dir <checkout> session lacks <plugins>/cache/<marketplace> structure, so the derivation yields no authority and the engine lane is deliberately fail-closed in that dev-only mode. Confirmed resolved.
  • Codex P2 (kill-switch doc accuracy): 6f05652 corrects both the README.md security-review bullet and the kill_switch_probe.py docstring to state that audit-only mode is model-enforced (via the probe), not guard-enforced. Both now accurately describe the current behavior. Confirmed resolved.
  • Core fix: The hook args change from ${CLAUDE_PLUGIN_DATA}--plugin-root ${CLAUDE_PLUGIN_ROOT} is sound. Seam-lock tests lock the config↔code contract.
  • Fail-closed derivation: _plugin_data_root_from_root returning None on unrecognized layout flows correctly to deny on --data-root calls while keeping the guard active.
  • No injection surfaces in _literal_shell_words: correctly blocks all shell expansion/operator characters; _data_root_key()resolve(strict=False) prevents ..-traversal in model-supplied paths; _PLUGIN_ID_DISALLOWED sanitisation prevents path traversal through marketplace/name components.

@kyle-sexton
kyle-sexton merged commit 0f04bd8 into main Jul 22, 2026
25 checks passed
@kyle-sexton
kyle-sexton deleted the fix/983-skill-hook-data-root branch July 22, 2026 18:39
kyle-sexton added a commit that referenced this pull request Jul 22, 2026
kyle-sexton added a commit that referenced this pull request Jul 22, 2026
…ket-denying non-OS drives (#1063)

> [!NOTE]
> Supersedes #1026 — same fully reviewed, CI-green content rebuilt as a
single commit on current
> main (version reconciled to 0.6.4 over #1014's 0.6.3). See #1026 for
the complete review history;
> all its review threads were resolved there.

## Summary

`disk-hygiene`'s `clean` skill rejected **any** filesystem/volume root
as an audit target purely
structurally, with no reasoning about the volume's purpose. This wrongly
blocked a legitimate non-OS
volume such as a Windows **Dev Drive** (`D:\`, ReFS, no OS content) with
no path to override,
interrogate, or explain. Root classification is now **reasoned**: a
genuine OS-managed root stays
denied; a non-OS volume root becomes a valid target that composes with
the large-target scan gate.

Fixes #984

## Root cause (corrected from the issue's premise)

The issue cited `hard_protection()` (the `path.parent == path` branch)
as the blanket rejection.
Empirically, that branch is **dead code for drive roots** in the live
call graph — candidates are
validated non-root descendants of the target, so it never fires on a
drive root. The real
user-facing block on Windows was the **mount-point check** in `main()`
scan: every Windows drive
letter is `os.path.ismount() == True`, so `D:\` was rejected as a "mount
point" *before* the
root check the issue points at (verified live: `os.path.ismount('D:/')
== True`; scanning `D:\`
returned `{"error": "mount points are not valid audit targets"}`).
Fixing only the cited line would
have left the bug unfixed. `preview()` applied the same checks in a
different order, a latent
inconsistency.

## Fix

- **Reasoned OS-managed classification** (`is_os_managed_target`): a
root is denied when it is *within*
an OS-managed root (full `system_roots()` set) or *holds an existing*
OS-install marker
(`os_drive_markers`). The OS-install markers deliberately **exclude the
per-volume metadata every
Windows volume carries** (`System Volume Information`, `$Recycle.Bin`) —
a Dev Drive has those too,
so counting them would misclassify every drive root as OS-managed.
`WINDOWS_VOLUME_SYSTEM_NAMES` is
split into `WINDOWS_OS_DRIVE_MARKERS` ∪ `WINDOWS_PER_VOLUME_METADATA`;
the union is unchanged, so
  per-entry protection of those folders on every drive is preserved.
- **Mount rejection scoped to non-root mounts** (`mounted and not
is_volume_root(target)`): a
volume-root target (inherently a mount point) falls through to the
reasoned root logic; nested and
  bind mounts stay hard-blocked — the real cross-boundary danger.
- **Scan and preview unified** to one `unverified → OS-managed →
non-root-mount` target-check order,
  removing the latent ordering inconsistency.
- **Composes with the large-target gate (#985 / #1010), reusing its
vocabulary — no parallel gate.**
`large_scan_reasons` now names a non-OS volume root a known-large root
(reason
`non-os-volume-root`), so an unbounded whole-volume walk returns
`large-target-confirmation-required`
(exit 5) unless bounded with `--max-depth` or confirmed with
`--confirmed-large-scan`. No
`destructive_guard` change is needed — #1010 already permits that flag.
- `hard_protection`'s (dead-for-drive-roots) branch is made reasoned
rather than blanket, for
  faithfulness, and locked with a direct unit test.

Deletion safety is unchanged: per-entry OS-managed / mount / VCS /
identity protections, the preview,
and per-tier approval all still gate any removal. The design **degrades
safely** — if OS-drive
markers were somehow absent on a real OS drive, the result is
confirmation-required plus full
downstream gating, never a silent dangerous delete. A stray non-OS
`D:\Windows` folder classifies the
volume as OS-managed (conservative false-positive deny), which is
acceptable.

## Test evidence

- `test_hygiene.py`: **101 tests pass** (added classification,
`os_drive_markers` metadata-exclusion,
`hard_protection` reasoning, non-root-mount rejection, OS-managed deny
in scan+preview, and
non-OS-volume-root large-target-gate tests; existing `system_roots`
protection test stays green).
- `ruff check` clean; `python -m py_compile` clean.
- Repo gates green: `check-changelog-parity --check-bump`,
`check-changed-skills` (skill-quality
static contract — 0 errors), `validate-plugins` (manifests + catalog),
`check-orphaned-fixtures`.
- **Live verification on the audited machine**: `C:\` → denied
(`OS-managed roots are not valid audit
targets`); `D:\` (Dev Drive) with no bound →
`large-target-confirmation-required` (reason
`non-os-volume-root`); `D:\ --max-depth 1 --confirmed-large-scan` →
`scan-complete`.

## Independent review

Reviewed by a fresh-context reviewer (rationale withheld, adversarial).
Verdict: **classification core
sound** — every branch hand-traced, the pathlib root-identity edge
verified empirically, no path where
an OS drive slips through as non-OS or a Dev Drive is wrongly denied,
and no deletion-safety
regression. No code changes required. Three low-confidence advisories,
noted for transparency (no
action taken):

1. `hard_protection`'s volume-root branch is unreachable on the live
call path (candidates are
validated non-root descendants) — kept as defense-in-depth and locked by
a direct unit test.
2. `system_roots()` and `os_drive_markers()` each call
`windows_drive_roots()`, so a scan makes a
couple of redundant `GetLogicalDrives` syscalls per invocation —
negligible, not hot-path.
3. Known limitation: a stray `Recovery` (or other
OS-install-marker-named) folder on a non-boot drive
would classify that volume as OS-managed. This fails safe
(over-restrictive deny, never an unsafe
   allow) and is the conservative side to err on.

## Fresh-docs note

Per the repo's fresh-docs mandate: this change is Python engine logic
that reuses existing status
vocabulary and adds no new plugin component type, manifest field, hook,
or skill surface — no official
Claude Code plugin-schema page is load-bearing here, so none is cited.
The only external behavior
reused (`--confirmed-large-scan`, `large-target-confirmation-required`)
is this plugin's own #1010
contract, referenced directly.

## Related

- #983, #985, #986 — sibling issues from the same disk-hygiene audit,
addressed in parallel PRs.
- #985 (PR #1010) — the companion large-target scan gate; this PR builds
on its
`large_scan_reasons` / `--confirmed-large-scan` mechanism rather than
adding a parallel gate.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01SFq1q99cNeZjKhDzHv2BQw

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
kyle-sexton added a commit that referenced this pull request Aug 13, 2026
The belt bullet asserted a remedy ("moving it to the shared launcher") that
#2568 does not commit to — the #1014 constraint on skill-hook substitution
argues against assuming the wired-hook shape transfers. A safety doc should
name what is tracked, matching the wording already used in test_hygiene.py.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Aac8xjCjMxFsXGHCXKHY4W
kyle-sexton added a commit that referenced this pull request Aug 13, 2026
…d destructive guard (#2570)

## Problem

`disk-hygiene`'s `PreToolUse` destructive-operation guard **has not been
running at all** on Windows hosts whose `PATH` resolves `bash` to the
WSL relay. This is a dead safety guard, not log noise:
`destructive_guard.py` never executes, so destructive
`Bash`/`PowerShell` commands proceed ungated while the operator sees
only `PreToolUse:Bash hook error` lines.

Both registrations in `hooks/hooks.json` used **exec form**:

```json
{ "type": "command", "command": "bash", "args": ["${CLAUDE_PLUGIN_ROOT}/hooks/run-python-hook.sh", "..."], "timeout": 60 }
```

### Reproduction

```console
$ where.exe bash            # from PowerShell, i.e. the real Windows PATH
C:\Windows\System32\bash.exe
C:\Users\<user>\AppData\Local\Microsoft\WindowsApps\bash.exe
```

Git Bash's directory is not on the Windows `PATH` at all, so the WSL
relay wins. Invoking the launcher through it:

```console
$ printf '{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"rm -rf /"}}' \
    | /c/Windows/System32/bash.exe plugins/disk-hygiene/hooks/run-python-hook.sh \
        plugins/disk-hygiene/skills/clean/scripts/destructive_guard.py --mode engine-gate
<3>WSL (31 - Relay) ERROR: CreateProcessCommon:818: execvpe(/bin/bash) failed: No such file or directory
EXIT=1
```

The only installed WSL distro is `docker-desktop`, which has no
`/bin/bash`.

### Why it is silent

Per the [hooks reference](https://code.claude.com/docs/en/hooks):

> **Exec form** runs when `args` is present. Claude Code resolves
`command` as an executable on `PATH` and spawns it directly with `args`
as the argument vector, with no shell involved. … No shell tokenization
happens on any platform.

> On Windows, exec form requires `command` to resolve to a real
executable such as a `.exe`.

And a hook that cannot start is **non-blocking**:

> A hook that can't start lands in the same non-blocking bucket. … For
most hook events, the action proceeds. When you set up a policy hook,
watch for this notice on its first run: a mistyped path in
`settings.json` leaves the gate silently disabled.

So "guard never ran" and "guard approved" are indistinguishable from
outside.

### This is a regression, and the repo already knew the rule

`0.17.6` moved both registrations onto `"command": "bash"` + `args` to
fix Python resolution (#1504) — reintroducing the exact launch failure
#1006 had already fixed on the skill-frontmatter surface. #1416 was
closed `COMPLETED` while the guard stayed dead; it is **reopened** by
this PR.

`plugins/claude-config/skills/audit/reference/audit-checklist.md`
Category D already carries this as an `error` row naming this precise
failure — and `disk-hygiene` shipped it anyway. A checklist a human
reads is not a gate; see #2569.

## Fix

Shell form, which Claude Code routes through **its own Git Bash** on
Windows rather than a `PATH` lookup:

> **Shell form** runs when `args` is absent. The `command` string is
passed to a shell: `sh -c` on macOS and Linux, Git Bash on Windows, or
PowerShell when Git Bash isn't installed. Set the `shell` field to
choose explicitly.

```json
{
  "type": "command",
  "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/run-python-hook.sh \"${CLAUDE_PLUGIN_ROOT}\"/skills/clean/scripts/destructive_guard.py --mode engine-gate --plugin-root \"${CLAUDE_PLUGIN_ROOT}\" --authorized-data-root \"${CLAUDE_PLUGIN_DATA}\"",
  "shell": "bash",
  "timeout": 60
}
```

The #1504 Python-resolution behaviour is untouched — only the launch
mechanism moves.

**Design notes**

- **Every placeholder is double-quoted**, per the page's "In shell form,
wrap each placeholder in double quotes." Verified for **both** hooks
that the resulting argv is byte-identical to the exec-form vector,
against a `CLAUDE_PLUGIN_ROOT` *and* a `CLAUDE_PLUGIN_DATA` containing
spaces and backslashes. The Stop hook was checked explicitly:
`guard_launch_monitor.py` is what emits the `systemMessage` when no
interpreter resolves, so a mangled `--data-root` there would have broken
the detector that makes a dead guard visible.
- **`"shell": "bash"` is declared explicitly**, unlike sibling
`guardrails` which omits it. Shell form otherwise falls back to
PowerShell on a Windows host with no Git Bash detected, which cannot run
a `.sh`. This is the spelling `claude-config`'s Category D row names as
the fix, and the one #1006 used. It is ignored when `args` is set, so it
is meaningful only in shell form.
- **Exec form with a real binary was not viable here.** The launcher is
a `.sh`, so it needs bash — and there is no portable spelling of Git
Bash's `bash.exe` to put in `command`. That is circular, which is why
the page's general "prefer exec form for path placeholders" preference
(a quoting-safety point) yields to the Windows platform constraint.
- No environment workaround. `CLAUDE_CODE_GIT_BASH_PATH` and `PATH`
reordering are explicitly **not** the fix; shell form is resolved by
Claude Code, so `PATH` order neither causes nor fixes this.

## Tests fixed — both encoded the bug as the contract

This is why the defect survived two fix attempts:

- `hooks/run-python-hook.test.sh` asserted `.command == "bash"` and read
`.args[0]`. It now asserts the **portability property**: launcher named
in `command`, `args` absent, `shell: bash`, and every
`${CLAUDE_PLUGIN_*}` placeholder double-quoted. Verified it **fails**
against the old exec-form JSON.
- `skills/clean/scripts/test_hygiene.py` selected guard hooks with `if
hook.get("args") and …`, which matches nothing once a hook moves to
shell form — every assertion built on it would have gone **vacuously
green**. Replaced with a form-agnostic `_hook_argv()` (`shlex.split` for
shell form, `[command, *args]` for exec form) plus
`_guard_argv_from_hook()`.

## Audit — full list of instances

Audited **every tracked JSON file** repo-wide via a recursive walk for
`type: command` + `args` — which covers the 18 `hooks/hooks.json` files,
any manifest-pointed hook config, and **inline `hooks` objects declared
in a `plugin.json`** — plus **every** SKILL.md and agent-definition YAML
frontmatter `hooks:` block across all 77 plugins.

| # | Location | Shape | Status |
| --- | --- | --- | --- |
| 1 | `plugins/disk-hygiene/hooks/hooks.json` — `PreToolUse` | exec
form, `"command": "bash"` | **Fixed here** (proven dead) |
| 2 | `plugins/disk-hygiene/hooks/hooks.json` — `Stop` | exec form,
`"command": "bash"` | **Fixed here** (proven dead) |
| 3 | `plugins/disk-hygiene/skills/clean/SKILL.md` frontmatter | exec
form, `"command": "python3"` | **Deferred → #2568** (latent) |

No other plugin in the repo has this defect — `disk-hygiene` is the only
one using exec form at all. Every other hook, including all of
`guardrails`, is already quoted shell form.

**Why #3 is deferred rather than fixed here:** it is *latent*, not live
— on the reporting host `python3` resolves to a real binary, so that
guard currently works. It would fail only where `python3` is the
zero-length `WindowsApps` alias stub. Converting a **currently-working**
safety guard on a premise not verifiable in CI risks killing a live
guard, which is the exact harm this PR fixes. #2568 carries the full
analysis and the #1014 constraint (skill hooks receive only
`${CLAUDE_PLUGIN_ROOT}`; `--authorized-data-root` must not be
reintroduced).

## Verification

- `run-python-hook.test.sh` — **13/13 PASS**; confirmed it fails against
the pre-fix `hooks.json`.
- Full `disk-hygiene` suite (5 files, 250 + 23 tests) — **no new
failures**. Three telemetry-sink timing failures reproduce locally on
Windows but are confirmed **pre-existing on unmodified `origin/main`**
via a detached baseline worktree, and the `hygiene` lane is green on
Linux CI.
- `setup` shipped no `evals/evals.json`, which the skill-quality gate
requires once its SKILL.md changes; six cases were added and pass both
the schema and `check-evals-quality.sh`.
- `shellcheck -x` (repo `.shellcheckrc`) clean; `typos`,
`markdownlint-cli2`, `check-hook-userconfig-argv.sh`,
`check-shell-portability.sh`, and `check-changelog-parity.sh`
(`--check`, `--check-bump`, `--check-order`) all green.
- Live corroboration: `guardrails`' quoted shell-form hooks fired and
blocked a command during this session on the same machine — the pattern
is demonstrably working here.
- Version bumped `0.17.7` → `0.17.8` with a CHANGELOG entry framing it
honestly as a regression.

## Docs corrected

Both stated the now-disproved premise, and one was actively harmful:

- `README.md` claimed the wired hooks "register as `bash` … so `bash`
must resolve on `PATH`".
- `skills/setup/SKILL.md` preflight item 1 told operators to **reorder
their Windows `PATH`** so Git Bash precedes stub paths — a check that
passes while the guard is dead, prescribing the workaround rather than
the fix. It now verifies the registration shape and explicitly says
`PATH` ordering is not the cause.
- `hooks/run-python-hook.sh`'s header comment asserted the launcher "is
registered as `bash` (available in Git Bash on Windows)" — the exact
false assumption. Rewritten to document shell form and warn against
reverting to exec form.

Closes #1416

## Related

- #2568 — the remaining skill-scoped `python3` instance, split out of
this audit
- #2569 — proposed repo-wide CI gate for this defect class, modelled on
`check-hook-userconfig-argv.sh`
- #1504 — the 0.17.6 Python-resolution fix that introduced this
regression
- #1006 — the original shell-form fix for this defect class
- #1014 — established the skill-hook substitution allowlist
- #1346 — earlier report of the same silently-unenforced guard

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01Aac8xjCjMxFsXGHCXKHY4W

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
kyle-sexton added a commit that referenced this pull request Aug 13, 2026
#2572)

## Problem

`plugins/disk-hygiene/skills/clean/SKILL.md`'s frontmatter belt was the
**third and last**
exec-form hook registration, deliberately left unconverted by #2570 (row
3 of that PR's audit
table):

```yaml
- type: command
  command: "python3"
  args: ["${CLAUDE_PLUGIN_ROOT}/skills/clean/scripts/destructive_guard.py", "--plugin-root", "${CLAUDE_PLUGIN_ROOT}"]
  timeout: 60
```

Per the [hooks reference](https://code.claude.com/docs/en/hooks), exec
form (`args` present)
resolves `command` as an executable on `PATH` with no shell, and "On
Windows, exec form requires
`command` to resolve to a real executable such as a `.exe`." On stock
Windows `python3` resolves to
`%LOCALAPPDATA%\Microsoft\WindowsApps\python3.exe` — a **zero-length App
Execution Alias stub**,
not a real executable. The spawn fails, a failed hook launch is
non-blocking, and the belt silently
enforces nothing.

This registration has named a bare interpreter since #215, predating
`hooks/run-python-hook.sh`
(#1504) entirely — the launcher that exists precisely to reject that
stub.

**This one is latent, not dead.** It works wherever `python3` is a real
interpreter, which is the
reporting host. So the risk here is the inverse of #2570's: breaking a
*working* safety guard.
Everything below is scoped to proving that cannot happen.

## Fix

Shell form through the shared launcher — the shape #2570 established, no
third variant:

```yaml
- type: command
  command: '"${CLAUDE_PLUGIN_ROOT}"/hooks/run-python-hook.sh "${CLAUDE_PLUGIN_ROOT}"/skills/clean/scripts/destructive_guard.py --plugin-root "${CLAUDE_PLUGIN_ROOT}"'
  shell: bash
  timeout: 60
```

**On the YAML spelling.** This is the *same value* `hooks.json` carries;
only the file-level escape
differs. JSON needs `\"` escapes, YAML expresses it as a single-quoted
scalar wrapping literal
double quotes. It is not a third shape — `_hook_argv()` tokenizes both
identically.

**Shell form is honored on the skill-frontmatter surface — verified, not
assumed.** #1014 found this
surface more restricted than documented, so this was checked rather than
inferred: `repo-hygiene`'s
`skills/clean/SKILL.md` has shipped a shell-form frontmatter hook with
`shell: bash` since #1006
(`a83d1748`, titled "shell-form hook launch"), and still carries it on
`main` today. A live,
working, same-repo precedent on the exact surface.

**The #1014 constraint is respected.** Verified in code, not from a
summary:
`destructive_guard.py:686-687` resolves `--plugin-root`, rejecting the
literal unexpanded
placeholder, and derives the data root from it via
`_plugin_data_root_from_root`;
`resolve_authorized_data_root`'s own docstring records that "a plugin
`hooks.json` hook can [supply
`${CLAUDE_PLUGIN_DATA}`]; a skill hook cannot." The new command string
therefore substitutes
**only** `${CLAUDE_PLUGIN_ROOT}` — no `${CLAUDE_PLUGIN_DATA}`, no
`${user_config.*}`, and
`--authorized-data-root` is **not** reintroduced. This is asserted by an
existing test that was made
form-agnostic rather than left form-specific.

## Argv equivalence — the load-bearing evidence

**Claim, scoped precisely:** the argv **`destructive_guard.py` itself
receives** is byte-identical
before and after. `argv[0]` necessarily changes — from the interpreter
name `python3` to the
launcher path — because replacing interpreter resolution with the
launcher *is* the fix.

Verified two ways. First with Python's `shlex` (the tokenizer
`test_hygiene.py` uses), then against
**a real bash**, because a claim about how a shell tokenizes should be
proven by a shell. The
launcher path is swapped for an argv dumper so the vector it would
receive is directly observable:

```console
root = C:\Program Files\Claude Code\plug in root
  shell sees: "C:\Program Files\Claude Code\plug in root"/hooks/run-python-hook.sh "C:\Program Files\Claude Code\plug in root"/skills/clean/scripts/destructive_guard.py --plugin-root "C:\Program Files\Claude Code\plug in root"
  argv built by bash (launcher swapped for an argv dumper):
    C:\Program Files\Claude Code\plug in root/skills/clean/scripts/destructive_guard.py
    --plugin-root
    C:\Program Files\Claude Code\plug in root

root = D:\a b\c\d
  argv built by bash (launcher swapped for an argv dumper):
    D:\a b\c\d/skills/clean/scripts/destructive_guard.py
    --plugin-root
    D:\a b\c\d
```

Exactly three tokens after the launcher, matching the exec-form `args`
array element for element,
for a POSIX root and for two Windows roots containing **both spaces and
backslashes**. These are the
same three roots the added test asserts against.

**Why it holds, and why the quoting is required rather than stylistic:**
inside POSIX double quotes a
backslash is literal (it escapes only `$`, `` ` ``, `"`, `\`, newline),
and whitespace does not split.
Remove the double quotes and `C:\Program Files\Claude Code\plug in root`
splits into four argv
entries and the backslashes get eaten — the guard would receive a
truncated `--plugin-root` and lose
its data-root authority. That is exactly why #2570's "double-quote every
placeholder" rule is a
correctness requirement.

## Closest reproducible proxy for "the guard still blocks"

The belt is skill-scoped, so it fires only inside `/disk-hygiene:clean`
and cannot be exercised from
an ordinary session — I am **not** claiming a live in-session
verification of the belt itself.
Instead, the exact new command string was substituted the way Claude
Code substitutes it and driven
end to end:

```console
$ printf '{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"rm -rf /tmp/example"}}' \
    | bash -c '"<root>"/hooks/run-python-hook.sh "<root>"/skills/clean/scripts/destructive_guard.py --plugin-root "<root>"'
{"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny",
 "permissionDecisionReason": "Disk-hygiene fails closed: Bash is restricted to exact bundled scan,
 preview, handoff-verify, and apply invocations of ... hygiene.py ..."}}
```

The belt launches through the new string and emits a `deny`. Note for
anyone re-running this: the
belt is **deny-by-default** during active cleanup (it allows only the
exact bundled engine
invocations), so a benign payload such as `echo hi` denies too. Both
denying is the documented
behavior of this surface, not over-blocking introduced here.

Composed with the argv equivalence above, the argument is stronger than
a single live observation:
the guard receives a byte-identical vector, the existing 252-test suite
proves the guard's decisions
*given* that vector, and
`test_skill_hook_launcher_resolves_a_supported_interpreter` proves the
launcher resolves and execs end to end. No behavior change is reachable
— only the launch mechanism
moved.

## What this does NOT fix — stated so it is not overclaimed

`run-python-hook.sh` **exits 0 silently in guard mode** when no
interpreter resolves anywhere on its
ladder (its own test asserts this). So this closes *"the belt cannot
start against the alias stub"*.
It does **not** make the belt fail-closed on a host with no Python at
all — that residual is
unchanged, and is documented as such in `safety-model.md` and the
README.

## Tests — the third bug-as-contract site, and its fix

#2568 named one form-specific helper. There were **three** sites, all of
which encoded the launch
form they were supposed to be checking:

| Site | Old shape | Post-conversion behavior |
| --- | --- | --- |
| `_skill_hook_command_and_args()` | `next(… "args:" in line)` |
**raises `StopIteration`** |
| same helper's `command` read | `.split(":",1)[1].strip().strip('"')` |
silently mis-parses a single-quoted YAML scalar into a string `shlex`
then tokenizes wrongly |
| `test_skill_hook_interpreter_is_python3_and_resolves` |
`assertEqual("python3", interpreter)` | asserts the defect |

The helper is replaced by `_skill_hook()`, which parses the frontmatter
into a **hook mapping** and
feeds it to the *existing* form-agnostic `_hook_argv()` /
`_guard_argv_from_hook()` added by #2570 —
so both surfaces are now asserted through one reading path, in either
form. A `_yaml_flow_scalar()`
helper decodes the three one-line YAML scalar styles rather than
hand-stripping quotes (the suite is
stdlib-only; PyYAML is not a dependency). The interpreter test now
**exercises the launcher's real
resolution ladder** instead of restating it, so the two cannot drift.

**Added — and proven discriminating (requirement 5):**

- `test_skill_hook_registers_in_portable_shell_form` — the same four
portability properties
`run-python-hook.test.sh` asserts for `hooks.json` (launcher named in
`command`, `args` absent,
`shell: bash`, every `${CLAUDE_PLUGIN_*}` double-quoted). That suite is
jq-based and cannot read
YAML frontmatter, which is why this surface is asserted in
`test_hygiene.py`.
**Verified to FAIL against the pre-change frontmatter** (`git stash` of
SKILL.md only):

  ```
  FAIL: test_skill_hook_registers_in_portable_shell_form
  AssertionError: 'hooks/run-python-hook.sh' not found in 'python3' :
    skill hook must launch through the shared launcher: 'python3'
  ```

Note the substitution-allowlist test passes in **both** forms, so it is
explicitly *not* the
  discriminator — its docstring now says so.
- `test_skill_hook_argv_matches_the_exec_form_vector_it_replaced` —
encodes the equivalence proof
above over three roots (POSIX, and two Windows roots with spaces +
backslashes). This one passes
in both forms *by design*: it is the equivalence evidence, not the
regression gate.

## Docs corrected (requirement 6)

Every surface that described this hook's form:

- `README.md` — "Claude Code launches the skill-scoped guard in
shell-free **exec form**"; "the
**wired** hooks resolve Python through `run-python-hook.sh`" (x2, now
all three).
- `skills/clean/reference/safety-model.md` — "(the clean skill's
frontmatter hook, **still exec
form**)"; "The skill-scoped belt is a separate surface and **still
launches in exec form via
`python3`** (#2568)" (sentence's purpose is gone); the "Hook launch
form" section extended from
two hooks to three; "since #1504 **both wired hooks** launch through the
shared launcher".
- `skills/setup/SKILL.md` — "the skill-scoped belt through the **literal
name `python3`**"; preflight
item 1's scope; item 2's "The `clean` guard hook runs the literal
command `python3`".
- `hooks/run-python-hook.sh` header — "Launch disk-hygiene **wired**
hooks"; "**hooks.json** invokes
  this file in SHELL FORM".
- `skills/setup/scripts/python3_alias_probe.py` docstring + operator
message, and
`test_python3_alias_probe.py` docstring — both grounded the check on
"the guard is launched as
  `python3`".

**Also changed — fallout of the conversion, caught in review (thread
from
`chatgpt-codex-connector`, P2):**

`/disk-hygiene:setup check` step 2 verdicts the interpreter **ladder**,
not `python3` alone. My
first pass kept `store-alias-stub` → FAIL and merely re-grounded the
rationale. That was wrong, and
the review found the concrete counter-case: a host with real Python
installed **without "Add to
PATH" but with the `py` launcher** has a stubbed `python3`, a working
`py -3`, and a guard that now
launches on every call — and would have been reported broken and told to
reinstall Python. Before
this PR that FAIL was correct (the belt really did launch as bare
`python3`); the conversion is what
made it a false positive, so fixing it belongs here.

Step 2 now treats the alias probe as **diagnostic input** — it still
exists to classify the first
rung *without executing it*, since a bare `python3 --version` pops the
Store — resolves the ladder
in the launcher's own order skipping stubs, and checks the selected
interpreter against the parsed
`MIN_PYTHON`. This is strictly more accurate in both directions, not a
safety downgrade:

- **FAIL** on an **exhausted** ladder or a below-floor interpreter — the
real fail-open. Both still
  FAIL under a disabled toggle, for the reason already in the file.
- **WARN** when the ladder resolves a supported interpreter but
`python3` is the stub. Guards
launch; the residual is only that a bare `python3` typed by hand still
opens the Store.

The probe's own return values are unchanged (27/27 setup tests still
green) — only the verdict
mapping and the message wording moved.

**Not changed, deliberately:**

- **`safety-model.md`'s "`${CLAUDE_PLUGIN_ROOT}` — the only substitution
a skill-frontmatter hook
receives"** — still true, still the #1014 constraint, and now
load-bearing for this shape.
- **`scripts/check-hook-userconfig-argv.sh`** — audited for the
vacuous-green risk #2570 found. It
scopes to hook *config JSON* only (`hooks/hooks.json`, manifest-pointed
configs, inline manifest
`hooks`), so SKILL.md frontmatter was never in scope and this change
cannot make it silently pass.
A repo-wide gate covering the frontmatter surface is what **#2569**
proposes; not built here.

## Verification

- **`test_hygiene.py` — 252 tests**, 1 failure.
`check-changed-skills.sh` reports 2 script-test
failures for `clean`. All three are the **pre-existing Windows
telemetry-sink timing failures**
#2570 baselined — confirmed identical on **unmodified `origin/main`**
via a detached baseline
worktree (`250 tests, same single failure` in `test_hygiene.py`; `23
tests, same 2 failures` in
`guard_launch_monitor.test.sh`). Count moves 250 → 252 (two added, one
renamed). The `hygiene`
  lane is green on Linux CI.
- `hooks/run-python-hook.test.sh` — **13/13 PASS**.
- `skills/setup/scripts` — 27/27 PASS.
- `markdownlint-cli2` (5 files) — 0 errors. `typos` — clean. `shellcheck
-x` — clean.
`ruff check` — all checks passed (`ruff format` drift in these files is
pre-existing on `main` and
  is not a CI gate).
- `check-changelog-parity.sh --check`, `--check-bump origin/main`,
`--check-order` — all green.
- `check-hook-userconfig-argv.sh`, `check-shell-portability.sh`,
`check-skill-portability.sh`,
  `check-silent-skips.sh`, `check-discriminating-test-skips.sh`,
  `check-manifest-duplicate-keys.py` — all green.
- All **5** `disk-hygiene` test entry scripts run individually:
`run-python-hook.test.sh`,
`kill_switch_probe.test.sh`, `python3_alias_probe.test.sh` PASS;
`guard_launch_monitor.test.sh`
and `hygiene.test.sh` carry only the three baselined failures above. (I
started
`scripts/run-plugin-tests.sh` for the whole repo but it exceeded its
window and was stopped, so
  I am **not** claiming a full-repo local run — CI covers that lane.)
- `machine-specific-paths` initially failed on the first push: the argv
fixtures used
`C:\Users\<name>\…` and `/home/user/…` roots. A correct catch — the
properties under test are
spaces, backslashes, and a drive-letter shape, none of which need a
home-directory spelling.
Fixtures moved to `/opt/claude/plugins/disk-hygiene`, `C:\Program
Files\Claude Code\plug in root`,
and `D:\a b\c\d`, and the evidence above was re-run against those exact
roots.
- Version bumped `0.17.8` → `0.17.9` with a CHANGELOG entry.
- No `lefthook` config exists in this repo (no `lefthook.yml` /
`.lefthook.yml`), so there is no
  such gate to run here.

Closes #2568

## Related

- #2570 — converted the two wired hooks; deliberately deferred this
instance as row 3 of its audit
- #1416 — the wired-hook instance of this defect class
- #1014 — established the skill-hook substitution allowlist this change
stays inside
- #1006 — the original shell-form fix, and the live precedent for shell
form on this surface
- #1504 — introduced `run-python-hook.sh`, which this belt now finally
routes through
- #2569 — proposed repo-wide CI gate for this defect class

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01Aac8xjCjMxFsXGHCXKHY4W

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

disk-hygiene: guard hook fails to launch on skill-frontmatter hooks referencing ${CLAUDE_PLUGIN_DATA} — live fail-open on 0.4.6

1 participant